import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:dbio_utils/generic_filehandle/generic_file_handle.dart';
import 'package:dbio_utils/gff/gff.dart';
import 'package:dbio_utils/gff/gff_parser.dart';

import 'util.dart' show formatItems, formatSequence;

GffTransform parseStream({
  bool parseFeatures,
  bool parseDirectives,
  bool parseComments,
  bool parseSequences,
  int bufferSize = 1000,
}) {
  return GffTransform(
    bufferSize: 1000,
    parseDirectives: true,
    parseComments: true,
    parseSequences: true,
    sync: false,
  );
}

Stream<BaseFeature> parseFile(
  GenericFileHandle file, {
  bool parseFeatures,
  bool parseDirectives,
  bool parseComments,
  bool parseSequences,
  int bufferSize = 1000,
}) {
  return file.openStream().transform<BaseFeature>(parseStream(
        parseFeatures: parseFeatures,
        parseDirectives: parseDirectives,
        parseComments: parseComments,
        parseSequences: parseSequences,
        bufferSize: bufferSize,
      ));
}

List parseStringSync(String str, Map options) {
  if (null == str) return [];
  var items = [];
  _push(item) {
    items.add(item);
  }

  var parser = GffParser(
    featureCallback: options['parseFeatures'] ? _push : null,
    directiveCallback: options['parseDirectives'] ? _push : null,
    commentCallback: options['parseComments'] ? _push : null,
    sequenceCallback: options['parseSequences'] ? _push : null,
    bufferSize: double.infinity.toInt(),
    errorCallback: (err) {
      throw Exception(err);
    },
  );

  str.split(RegExp('\r?\n')).forEach(parser.addLine);
  parser.finish();
  return items;
}

class GffTransform extends StreamTransformerBase<List<int>, BaseFeature> {
  GffParser _parser;

  String textBuffer = '';
  Encoding encoding;
  int bufferSize;
  int maxLineLength = 0;

  Stream<List<int>> _stream;
  StreamController<BaseFeature> _streamController;
  StreamSubscription<List<int>> _streamSubscription;
  bool cancelOnError;

  GffTransform({
    bool parseFeatures = true,
    bool parseDirectives = false,
    bool parseComments = false,
    bool parseSequences = true,
    this.encoding = utf8,
    this.bufferSize = 1000,
    bool sync = false,
  }) {
    textBuffer = '';
    _parser = GffParser(
      featureCallback: parseFeatures ? push : null,
      directiveCallback: parseDirectives ? push : null,
      commentCallback: parseComments ? push : null,
      sequenceCallback: parseSequences ? push : null,
      errorCallback: (err) => _streamController.sink.addError(err),
      bufferSize: bufferSize,
    );
    _streamController = StreamController<BaseFeature>(
      onListen: _onListen,
      onCancel: _onCancel,
      onPause: () => _streamSubscription?.pause(),
      onResume: () => _streamSubscription?.resume(),
      sync: sync,
    );
  }

  GffTransform.broadcast({bool sync: false, this.cancelOnError}) {
    _streamController = StreamController.broadcast(onListen: _onListen, onCancel: _onCancel, sync: sync);
  }

  void push(BaseFeature data) {
    _streamController?.sink?.add(data);
  }

  void _onListen() {
    _streamSubscription = _stream.listen(
      _transform,
      onError: _streamController.addError,
      onDone: () {
        _flush();
        _streamController.close();
      },
      cancelOnError: cancelOnError,
    )..onData(_transform);
  }

  void _onCancel() {
    _streamSubscription?.cancel();
    _streamSubscription = null;
  }

  // void _onData(List<int> data) {
  //   _transform(data);
  // }

  int lineCount = 0;

  _addLine(String data) {
    var line = data; //encoding.decode(data);
    if (line != null) {
      this._parser.addLine(line);
      lineCount++;
    }
  }

  _nextText(String buffer) {
    var pieces = (this.textBuffer + buffer).split(RegExp('\r?\n'));
    this.textBuffer = pieces.removeLast();

    if (this.maxLineLength > 0 && this.textBuffer.length > this.maxLineLength) {
      //this.emit('error', Exception('maximum line size exceeded'));
      _streamController.sink.addError(Exception('maximum line size exceeded'));
      return;
    }
    pieces.forEach(_addLine);
  }

  _transform(List<int> bytes) {
    this._nextText(this.encoding.decode(bytes));
  }

  _flush() {
//    if (this.encoding.end()) this._nextText(this.decoder.end());
    if (this.textBuffer != null) this._addLine(this.textBuffer);
    this._parser.finish();
    print('line count: $lineCount');
  }

  @override
  Stream<BaseFeature> bind(Stream<List<int>> stream) {
    this._stream = stream;
    return _streamController.stream;
  }
}

class FormattingTransform<S> extends StreamTransformerBase<S, String> {
  int linesSinceLastSyncMark;
  int minLinesBetweenSyncMarks;
  bool insertVersionDirective;
  bool haveWeEmittedData;
  bool fastaMode;

  Stream<S> _stream;
  StreamController<String> _streamController;
  StreamSubscription<S> _streamSubscription;
  bool cancelOnError;

  FormattingTransform({
    this.linesSinceLastSyncMark = 0,
    this.minLinesBetweenSyncMarks = 100,
    this.haveWeEmittedData = false,
    this.fastaMode = false,
    bool sync = false,
  }) {
    _streamController = StreamController<String>(
      onListen: _onListen,
      onCancel: _onCancel,
      onPause: () => _streamSubscription?.pause(),
      onResume: () => _streamSubscription?.resume(),
      sync: sync,
    );
  }

  void _onListen() {
    _streamSubscription = _stream.listen(
      _onData,
      onError: _streamController.addError,
      onDone: _streamController.close,
      cancelOnError: cancelOnError,
    );
  }

  void _onCancel() {
    _streamSubscription?.cancel();
    _streamSubscription = null;
  }

  void _onData(S data) {
    _transform(data, 'utf8', null);
  }

  push(String data) {
    _streamController?.sink?.add(data);
  }

  _transform(chunk, encoding, callback) {
    // if we have not emitted anything yet, and this first
    // chunk is not a gff-version directive, emit one
    var str;
    if (!this.haveWeEmittedData && this.insertVersionDirective && (chunk[0] ?? chunk).directive != 'gff-version') this.push('##gff-version 3\n');

    // if it's a sequence chunk coming down, emit a FASTA directive and
    // change to FASTA mode
    if (chunk.sequence != null && !this.fastaMode) {
      this.push('##FASTA\n');
      this.fastaMode = true;
    }

    if (chunk is List) {
      str = chunk.map((e) => formatItems(e)).join('');
    } else {
      str = formatItems([chunk]);
    }

    this.push(str);

    if (this.linesSinceLastSyncMark >= this.minLinesBetweenSyncMarks) {
      this.push('###\n');
      this.linesSinceLastSyncMark = 0;
    } else {
      // count the number of newlines in this chunk
      var count = 0;
      for (var i = 0; i < str.length; i += 1) {
        if (str[i] == '\n') count += 1;
      }
      this.linesSinceLastSyncMark += count;
    }

    this.haveWeEmittedData = true;
    callback?.call();
  }

  @override
  Stream<String> bind(Stream<S> stream) {
    this._stream = stream;
    return _streamController.stream;
  }
}

/// Format an array of GFF3 items (features,directives,comments) into string of GFF3.
/// Does not insert synchronization (###) marks.
///
/// @param {Array[Object]} items
/// @returns {String} the formatted GFF3
String formatSync(List items) {
  // sort items into seq and other
  List other = [];
  List sequences = [];
  items.forEach((i) {
    if (i.sequence != null) {
      sequences.add(i);
    } else
      other.add(i);
  });
  var str = other.map((e) => formatItems(e)).join('');
  if (sequences.length > 0) {
    str += '##FASTA\n';
    str += sequences.map(formatSequence).join('');
  }
  return str;
}

/// Format a stream of items (of the type produced
/// by this script) into a stream of GFF3 text.
///
/// Inserts synchronization (###) marks automatically.
///
/// @param {Object} options
/// @param {Object} options.minSyncLines minimum number of lines between ### marks. default 100
/// @param {Boolean} options.insertVersionDirective
///  if the first item in the stream is not a ##gff-version directive, insert one.
///  default false
formatStream(options) {
  return FormattingTransform();
}

/// Format a stream of items (of the type produced
/// by this script) into a GFF3 file and write it to the filesystem.
/// Inserts synchronization (###) marks and a ##gff-version
/// directive automatically (if one is not already present).
///
/// @param {ReadableStream} stream the stream to write to the file
/// @param {String} filename the file path to write to
/// @param {Object} options
/// @param {String} options.encoding default 'utf8'. encoding for the written file
/// @param {Number} options.minSyncLines
///  minimum number of lines between sync (###) marks. default 100
/// @param {Boolean} options.insertVersionDirective
///  if the first item in the stream is not a ##gff-version directive, insert one.
///  default true
/// @returns {Promise} promise for the written filename
formatFile(stream, filename) {
  return File(filename).openWrite().addStream(stream);
}
